iT邦幫忙

2024 iThome 鐵人賽

DAY 17
0
Python

Python 錦囊密技系列 第 17

【Python錦囊㊙️技17】單元測試(Unit Testing)入門

  • 分享至 

  • xImage
  •  

前言

依照【Python錦囊㊙️技10】OOA、OOD and OOP討論的軟體開發生命週期(SDLC),程式撰寫完後,必須進行單元測試(Unit Testing),確保程式正確且符合設計規格。
https://ithelp.ithome.com.tw/upload/images/20240925/20001976eWrNpgxVJd.png
圖一. 瀑布型模型(Waterfall Model)

測試要求

通常單元測試有幾項要求:

  1. 自動測試:能自動化且能迴歸測試(Regression testing),即程式有任何修改,測試案例能全部再測試一遍。
  2. 涵蓋範圍(Coverage)要廣:最好能測試到程式碼的每一個分支,數值能夠涵蓋正常值、異常值及邊界值。
  3. Mocking/Stubbing:程式與其他子系統或外部系統有互動(Interaction)時,可以透過Mocking/Stubbing模擬其他系統,使程式仍可獨立測試。

概念簡介

Python內建測試模組unittest,也有一個知名的套件PyTest,另外,有些開發框架(Framework)套件也有自己的測試模組,例如Django,可參閱【Testing in Django】。本文主要介紹Python內建模組unittest及pytest。

單元測試有幾個專有名詞說明如下:

  1. 測試案例(Test case):通常是以函數實作,內含斷言(Assert)。
  2. 測試套件(Test Suite):多個相關測試案例的集合。
  3. 測試夾具(Test fixture):測試前有時候要準備資料或資料庫/網路連線,在此函數內實現。
  4. 測試執行器(Test runner):執行測試並提供測試結果給使用者,unittest、pytest都屬於執行器(runner),有些測試工具會提供美觀的GUI畫面。另外,有些執行器會模擬滑鼠與鍵盤,協助進行網頁或桌面程式的測試,例如Selenium、pywinauto、AutoIt。

Python內建測試模組unittest

unittest以OOP方式程式開發,建立類別繼承unittest.TestCase,類別裡面的每個函數代表一個測試案例。

範例1. 簡單測試,程式來自【官方unittest文件】,程式名稱為17\test1.py。

  1. 引用unittest。
import unittest
  1. 建立類別繼承unittest.TestCase,撰寫函數測試【foo】大寫是否為(assertEqual)【FOO】。
# 測試類別,繼承unittest.TestCase
class TestStringMethods(unittest.TestCase):
    # 測試案例1:大寫
    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')
  1. 執行測試。
if __name__ == '__main__':
    # 執行測試
    unittest.main()
  1. 執行結果:python test1.py。
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
  1. 在類別內增加2個測試案例。
    # 測試案例2:測試 isupper 方法
    def test_isupper(self):
        self.assertTrue('FOO'.isupper())
        self.assertFalse('Foo'.isupper())

    # 測試案例3:測試分詞(split)
    def test_split(self):
        s = 'hello world'
        self.assertEqual(s.split(), ['hello', 'world'])
        # check that s.split fails when the separator is not a string
        with self.assertRaises(TypeError):
            s.split(2)
  1. 執行結果:第一列有3個點,表3個案例。
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK
  1. 將第3個案例的【hello world】改為【hello$world】。
s = 'helloxworld'
  1. 執行結果顯示有6個案例。:
  • 第一列有1個F,表失敗,為什麼在第2個? 應該是每個案例是並行處理,第3個案例提前執行完成。
  • 失敗案例會有詳細的說明,顯示assertEqual的2個輸入參數的比對。
.F.
======================================================================
FAIL: test_split (__main__.TestStringMethods.test_split)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "F:\0_python\00_MY\0_ITHome\src\17\test1.py", line 17, in test_split
    self.assertEqual(s.split(), ['hello', 'world'])
AssertionError: Lists differ: ['helloxworld'] != ['hello', 'world']

First differing element 0:
'helloxworld'
'hello'

Second list contains 1 additional elements.
First extra element 1:
'world'

- ['helloxworld']
?        ^

+ ['hello', 'world']
?        ^^^^

----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=1)
  1. 可單獨測試一個案例:python test1.py TestStringMethods.test_upper。
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
  1. 測試多個檔案:python -m unittest discover,會搜尋以【test】開頭的檔名,並全部執行。複製test1.py為test2.py,測試結果顯示有6個案例。
......
----------------------------------------------------------------------
Ran 6 tests in 0.000s
OK

範例2. 通常受測程式與測試程式會分開,甚至測試程式會儲存在另一個資料夾,通常會命名為tests,程式放在17\project1資料夾,規劃如下:
https://ithelp.ithome.com.tw/upload/images/20240930/20001976Pui99Pt7Sc.png

  1. 受測程式:17\project1\main_program\__init__.py,內容為計算階層(factorial)的函數。
# 階層(factorial )計算
from functools import reduce

def factorial(n):
    return reduce(lambda x, y: x * y, range(1, n+1))
  1. 測試程式:17\project1\test2.py,內容如下,可直接引用 main_program 資料夾__init__.py檔案內的factorial函數,命名為__init__.py的檔案類似Class的__init__。
import unittest

# 引用 main_program 資料夾的factorial函數
from main_program import factorial

class TestMath(unittest.TestCase):
    def test_factorial(self):
        # 測試階層計算是否正確
        self.assertEqual(factorial(5), 1*2*3*4*5)

if __name__ == '__main__':
    unittest.main()
  1. 執行結果:
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
  1. 如果測試程式放在另一個資料夾tests,要如何呼叫呢? 可使用sys.path.append,程式放在17\project2資料夾。
    https://ithelp.ithome.com.tw/upload/images/20240930/2000197608F4bvQfcG.png
import unittest, sys, os

# 將路徑加入環境變數
sys.path.append(os.path.abspath(".."))
  1. 執行結果:同上。

Assert

除了assertEqual,unittest還有許多Assert指令:
https://ithelp.ithome.com.tw/upload/images/20240930/20001976ZhXId2SuNT.png
圖一. Assert指令列表,圖片來源:【Getting Started With Testing in Python】

測試夾具(Test fixture)

測試前有時候要準備資料或資料庫/網路連線,可在setUp函數內實現,另外tearDown函數可關閉資源,例如資料庫/網路連線。

範例3. 在setUp函數準備資料,程式名稱為17\test_fixture.py。

  1. 受測函數為【質數檢查】(check_prime),如下:
# 檢查是否為質數
def check_prime(i):
    for j in range(2, math.floor(i/2)+1):
        if i % j == 0:
            return False
    return True
  1. 在setUp函數準備兩組資料接受測試。
import unittest
import math

# 檢查是否為質數
def check_prime(i):
    for j in range(2, math.floor(i/2)+1):
        if i % j == 0:
            return False
    return True

# 測試類別,繼承unittest.TestCase
class TestPrime(unittest.TestCase):
    def setUp(self):
        self.prime_number = [2,3,5,7,11]
        self.non_prime_number = [4,9,15,30]
        
    # 測試案例1:測試 prime 
    def test_prime(self):
        for i in self.prime_number:
            self.assertEqual(check_prime(i), True)

    # 測試案例2:測試 non prime 
    def test_non_prime(self):
        for i in self.non_prime_number:
            self.assertEqual(check_prime(i), False)

if __name__ == '__main__':
    # 執行測試
    unittest.main()
  1. 執行結果:
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK

結語

單元測試是程式設計師的當責工作,在交給測試部門進行整合測試前,必須確保每支程式的品質,避免在團隊合併程式測試時,造成處處暴雷的狀況,後果不可收拾。下一篇我們會繼續討論更多的測試技巧。

本系列的程式碼會統一放在GitHub,本篇的程式放在src/17資料夾,歡迎讀者下載測試,如有錯誤或疏漏,請不吝指正。


上一篇
【Python錦囊㊙️技16】Django網頁程式完整範例
下一篇
【Python錦囊㊙️技18】單元測試(Unit Testing)進階篇
系列文
Python 錦囊密技30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言